import functools
import itertools
import operator

from django.conf import settings
from django.db.models import signals
from django.dispatch import receiver
from django.utils import timezone
from simple_history.signals import post_create_historical_record, pre_create_historical_record

from sysreptor import signals as sysreptor_signals
from sysreptor.pentests.collab.text_transformations import ChangeSet, Update
from sysreptor.pentests.models import (
    ArchivedProject,
    CollabEvent,
    CollabEventType,
    Comment,
    CommentAnswer,
    FindingTemplate,
    FindingTemplateTranslation,
    PentestFinding,
    PentestProject,
    ProjectMemberInfo,
    ProjectNotebookExcalidrawFile,
    ProjectNotebookPage,
    ProjectType,
    ReportSection,
    ReviewStatus,
    ShareInfo,
    SourceEnum,
    UploadedAsset,
    UploadedImage,
    UploadedProjectFile,
    UploadedTemplateImage,
    UploadedUserNotebookFile,
    UploadedUserNotebookImage,
    UserNotebookExcalidrawFile,
    UserNotebookPage,
)
from sysreptor.pentests.models.collab import collab_context_store
from sysreptor.users.models import PentestUser
from sysreptor.utils.fielddefinition.mixins import CustomFieldsMixin
from sysreptor.utils.fielddefinition.types import FieldDataType, parse_field_definition
from sysreptor.utils.fielddefinition.utils import (
    HandleUndefinedFieldsOptions,
    ensure_defined_structure,
    get_field_value_and_definition,
    get_value_at_path,
    has_field_structure_changed,
    iterate_fields,
)
from sysreptor.utils.history import (
    HistoricalRecords,
    bulk_create_with_history,
    bulk_delete_with_history,
    bulk_update_with_history,
    history_context,
)
from sysreptor.utils.models import disable_for_loaddata
from sysreptor.utils.utils import copy_keys, omit_keys


@receiver(signals.pre_save, sender=PentestProject)
@disable_for_loaddata
def project_set_readonly_since(sender, instance, *args, **kwargs):
    if instance.readonly and not instance.readonly_since:
        instance.readonly_since = timezone.now()
        instance._history_prevent_cleanup = True
        instance._history_change_reason = 'Finished project'
    elif not instance.readonly and instance.readonly_since:
        instance.readonly_since = None
        instance._history_prevent_cleanup = True
        instance._history_change_reason = 'Re-activated project'


@receiver(signals.post_save, sender=PentestProject)
@disable_for_loaddata
def project_set_readonly(sender, instance, *args, **kwargs):
    if not (instance.readonly and 'readonly' in instance.changed_fields):
        return

    if settings.SIMPLE_HISTORY_ENABLED:
        # Prevent cleanup of history savepoints at time of marking the project readonly
        ReportSection.history.filter(project_id=instance.id).latest_of_each().update(history_prevent_cleanup=True)
        PentestFinding.history.filter(project_id=instance.id).latest_of_each().update(history_prevent_cleanup=True)
        ProjectNotebookPage.history.filter(project_id=instance.id).latest_of_each().update(history_prevent_cleanup=True)
        ProjectMemberInfo.history.filter(project_id=instance.id).latest_of_each().update(history_prevent_cleanup=True)
        UploadedImage.history.filter(linked_object_id=instance.id).latest_of_each().update(history_prevent_cleanup=True)
        UploadedProjectFile.history.filter(linked_object_id=instance.id).latest_of_each().update(history_prevent_cleanup=True)
        ProjectNotebookExcalidrawFile.history.filter(linked_object__project_id=instance.id).latest_of_each().update(history_prevent_cleanup=True)

    # Send signal
    sysreptor_signals.post_finish.send(sender=instance.__class__, instance=instance)


@receiver(signals.pre_save, sender=ShareInfo)
def share_info_reset_failed_password_attempts(sender, instance, *args, **kwargs):
    if not instance.is_revoked and 'is_revoked' in instance.changed_fields:
        instance.failed_password_attempts = 0


def update_project_report_sections_structure(projects, project_type, patch_previous_project_history=False):
    # Update structure of all reports using that project_type
    sections_to_create = []
    sections_to_delete = []
    sections_to_update = []
    comments_to_delete = []
    comments_to_update = []
    for project in projects:
        # Merge all report data (sections + project.unknown_custom_fields) and update structure
        data_all = ensure_defined_structure(
            value=(project.unknown_custom_fields or {}) | functools.reduce(operator.or_, map(lambda s: s.custom_fields, project.sections.all()), {}),
            definition=project_type.all_report_fields_obj,
            handle_undefined=HandleUndefinedFieldsOptions.FILL_DEFAULT,
            include_unknown=True,
        )
        comments = list(Comment.objects.filter(section__project_id=project.id))

        # Check which sections to create/delete/update
        sections = []
        existing_sections = {s.section_id: s for s in project.sections.all()}

        # New sections
        for sid in {s['id'] for s in project_type.report_sections} - set(existing_sections.keys()):
            section = ReportSection(project=project, section_id=sid)
            sections.append(section)
            sections_to_create.append(section)
        # Deleted sections
        for sid in set(existing_sections.keys()) - {s['id'] for s in project_type.report_sections}:
            sections_to_delete.append(existing_sections[sid])
        # Updated sections
        for sid in set(existing_sections.keys()).intersection({s['id'] for s in project_type.report_sections}):
            section = existing_sections[sid]
            sections.append(section)
            sections_to_update.append(section)

        # Distribute data to sections
        for section in sections:
            section_fields = set(f['id'] for f in next(filter(lambda s: s['id'] == section.section_id, project_type.report_sections))['fields'])
            section.custom_fields = {f: data_all.pop(f, None) for f in section_fields}

            # Move comments to other sections together with fields
            for c in list(comments):
                c_path_parts = tuple(c.path.split('.'))
                if len(c_path_parts) >= 2 and c_path_parts[0] == 'data' and c_path_parts[1] in section_fields:
                    try:
                        comments.remove(c)
                        c.section = section
                        _, _, definition = get_field_value_and_definition(data=section.custom_fields, definition=project_type.all_report_fields_obj, path=c_path_parts[1:])
                        if c.text_range and definition.type not in [FieldDataType.MARKDOWN]:
                            c.text_range = None
                        comments_to_update.append(c)
                    except KeyError:
                        # Comment path not found in new field definition
                        comments_to_delete.append(c)

        # Store remaining unknown fields on project
        project.unknown_custom_fields = data_all if data_all else None
        # Delete comments of removed fields
        comments_to_delete.extend(comments)

    # Persist changes in DB
    bulk_update_with_history(ReportSection, filter(lambda s: s.has_changed, sections_to_update), fields=['custom_fields'])
    bulk_create_with_history(ReportSection, sections_to_create)
    bulk_delete_with_history(ReportSection, sections_to_delete)
    bulk_update_with_history(Comment, comments_to_update, fields=['section_id', 'text_range_from', 'text_range_to'])
    bulk_delete_with_history(Comment, comments_to_delete)

    projects_to_update = list(filter(lambda p: p.has_changed, projects))
    if patch_previous_project_history and settings.SIMPLE_HISTORY_ENABLED:
        # Update the unknown_custom_fields in the previous history entry to have a clean history timeline
        PentestProject.objects.bulk_update(projects_to_update, fields=['unknown_custom_fields'])
        project_histories = PentestProject.history.filter(id__in=[p.id for p in projects]).latest_of_each()
        for ph in project_histories:
            ph.unknown_custom_fields = next(filter(lambda p: p.id == ph.id, projects)).unknown_custom_fields
        PentestProject.history.bulk_update(project_histories, fields=['unknown_custom_fields'])
    else:
        bulk_update_with_history(PentestProject, projects_to_update, fields=['unknown_custom_fields'])


def update_findings_structure(findings, project_type=None):
    updated_findings = list(findings.prefetch_related('comments'))
    finding_comments_to_update = []
    finding_comments_to_delete = []
    for finding in updated_findings:
        finding.update_data(ensure_defined_structure(
            value=finding.custom_fields,
            definition=project_type.finding_fields_obj,
            handle_undefined=HandleUndefinedFieldsOptions.FILL_DEFAULT,
            include_unknown=True))
        for c in finding.comments.all():
            try:
                _, _, definition = get_field_value_and_definition(data=finding.custom_fields, definition=project_type.finding_fields_obj, path=c.path.split('.')[1:])
                if c.text_range and definition.type not in [FieldDataType.MARKDOWN]:
                    c.text_range = None
                    finding_comments_to_update.append(c)
            except KeyError:
                # Comment path not found in new field definition
                finding_comments_to_delete.append(c)
    if settings.SIMPLE_HISTORY_ENABLED:
        # Prevent cleanup of history entries before applying design changes
        PentestFinding.history.filter(pk__in=map(lambda f: f.pk, updated_findings)).latest_of_each().update(history_prevent_cleanup=True)
    bulk_update_with_history(PentestFinding, objs=filter(lambda f: f.has_changed, updated_findings), fields=['custom_fields'])
    bulk_update_with_history(Comment, finding_comments_to_update, fields=['text_range_from', 'text_range_to'])
    bulk_delete_with_history(Comment, finding_comments_to_delete)


@receiver(signals.post_save, sender=PentestProject)
@disable_for_loaddata
def project_project_type_changed_postsave(sender, instance, created, *args, **kwargs):
    """
    When the project_type of a project changed, update the structure of all fields
    """
    # Project created or project_type changed
    if created or 'project_type_id' in instance.changed_fields:
        with history_context(history_prevent_cleanup=True, **({'history_change_reason': 'Design changed'} if not created else {})):
            # Update section and finding structure
            update_project_report_sections_structure(projects=[instance], project_type=instance.project_type, patch_previous_project_history=True)
            update_findings_structure(instance.findings.all(), project_type=instance.project_type)

    # New project created (but not copied or imported)
    if created and not instance.copy_of and instance.source != SourceEnum.IMPORTED:
        # Create notes from default notes
        notes = [
            ProjectNotebookPage(project=instance, note_id=n.get('id'), parent_id=n.get('parent'), **omit_keys(n, ['id', 'parent']))
            for n in (instance.project_type.default_notes or [])
        ]
        for note in notes:
            if note.parent_id:
                note.parent = next(filter(lambda n: n.note_id == note.parent_id, notes), None)
        bulk_create_with_history(ProjectNotebookPage, objs=notes)


@receiver(signals.post_save, sender=ProjectType)
@disable_for_loaddata
@history_context(history_change_reason='Field definition changed', history_prevent_cleanup=True)
def project_type_field_definition_changed(sender, instance, *args, **kwargs):
    """
    When report_sections or finding_fields structure changed, update the field structure of all projects that are based on this project_type
    """
    definition_structure_changed = False

    # Check if finding field definition changed
    if (diff := instance.get_field_diff('finding_fields')) and has_field_structure_changed(parse_field_definition(diff[0]), instance.finding_fields_obj):
        definition_structure_changed = True
        # Update structure of all findings of this project_type
        update_findings_structure(findings=PentestFinding.objects.filter(project__project_type=instance), project_type=instance)

    # Check if report section definition changed.
    # Report sections are considered as changed if field definitions changed or
    # sections are created/deleted or fields are moved between sections
    # Changes in the section definition are synced to the ReportSection models of projects.
    if report_sections_diff := instance.get_field_diff('report_sections'):
        report_sections_prev = set([(s['id'], tuple(sorted([f['id'] for f in s['fields']]))) for s in report_sections_diff[0]])
        report_sections_curr = set([(s['id'], tuple(sorted([f['id'] for f in s['fields']]))) for s in report_sections_diff[1]])
        report_sections_changed = (report_sections_prev != report_sections_curr)
        report_fields_changed = has_field_structure_changed(
            old=parse_field_definition(list(itertools.chain(*map(lambda s: s['fields'], report_sections_diff[0])))),
            new=instance.all_report_fields_obj)
        if report_fields_changed or report_sections_changed:
            definition_structure_changed = True
            projects = list(PentestProject.objects.filter(project_type=instance).select_related('project_type').prefetch_related('sections'))
            update_project_report_sections_structure(projects=projects, project_type=instance)

    if definition_structure_changed:
        # Send collab event
        from sysreptor.pentests.consumers import send_collab_event_project
        for project_id in PentestProject.objects.filter(project_type=instance).values_list('id', flat=True):
            send_collab_event_project(CollabEvent.objects.create(
                type=CollabEventType.UPDATE_KEY,
                related_id=project_id,
                path='project.project_type',
                version=timezone.now().timestamp(),
                created=timezone.now(),
                data={'value': instance.id},
            ))


@receiver(pre_create_historical_record)
@disable_for_loaddata
def history_set_details(instance, history_instance, origin=None, *args, **kwargs):
    history_instance.history_prevent_cleanup = getattr(history_instance, '_history_prevent_cleanup',
                                                       getattr(HistoricalRecords.context, 'history_prevent_cleanup', False))
    history_instance.history_date = getattr(instance, '_history_date', None) or \
        getattr(HistoricalRecords.context, 'history_date', None) or \
        timezone.now()
    history_instance.history_change_reason = getattr(instance, '_history_change_reason',
                                                     getattr(HistoricalRecords.context, 'history_change_reason', None))
    if history_type := getattr(instance, '_history_type', None):
        history_instance.history_type = history_type
    if history_instance.history_type in ['+', '-']:
        history_instance.history_prevent_cleanup = True
    if history_instance.history_type == '-' and isinstance(origin, PentestProject|ProjectType|FindingTemplate):
        # Prevent formatting errors on delete of main models
        return

    if not history_instance.history_title:
        if isinstance(instance, PentestProject):
            history_instance.history_title = instance.name
        elif isinstance(instance, ReportSection):
            history_instance.history_title = instance.section_label
        elif isinstance(instance, PentestFinding):
            history_instance.history_title = instance.data.get('title')
        elif isinstance(instance, ProjectNotebookPage):
            history_instance.history_title = instance.title
        elif isinstance(instance, ProjectMemberInfo):
            history_instance.history_title = instance.user.username
        elif isinstance(instance, ProjectType):
            history_instance.history_title = instance.name
        elif isinstance(instance, FindingTemplateTranslation):
            history_instance.history_title = instance.get_language_display()
        elif isinstance(instance, UploadedImage|UploadedProjectFile|UploadedAsset|UploadedTemplateImage):
            history_instance.history_title = instance.name

    # Model-specific change_reason
    if history_instance.history_type == '+' and isinstance(instance, ProjectMemberInfo):
        history_instance.history_change_reason = f'Added member @{instance.user.username}'
    elif history_instance.history_type == '-' and isinstance(instance, ProjectMemberInfo):
        history_instance.history_change_reason = f'Removed member @{instance.user.username}'
    elif not history_instance.history_change_reason:
        if history_instance.history_type == '~':
            if isinstance(instance, FindingTemplateTranslation):
                if 'language' in instance.changed_fields:
                    history_instance.history_change_reason = f'Language changed to {instance.get_language_display()}'
                elif 'status' in instance.changed_fields:
                    history_instance.history_change_reason = f'Status changed to {ReviewStatus.get_status_display(instance.status)}'
            elif isinstance(instance, FindingTemplate):
                if 'main_translation_id' in instance.changed_fields:
                    history_instance.history_change_reason = f'Main translation changed to {instance.main_translation.get_language_display()}'
            elif isinstance(instance, PentestFinding|ReportSection|ProjectNotebookPage):
                if 'status' in instance.changed_fields:
                    history_instance.history_change_reason = f'Status changed to {ReviewStatus.get_status_display(instance.status)}'
                elif 'assignee_id' in instance.changed_fields:
                    if instance.assignee:
                        history_instance.history_change_reason = f'Assignee changed to @{instance.assignee.username}'
                    else:
                        history_instance.history_change_reason = 'Unassigned'
            elif isinstance(instance, ProjectMemberInfo):
                if 'roles' in instance.changed_fields:
                    history_instance.history_change_reason = f'Roles of @{instance.user.username} changed'
            elif isinstance(instance, PentestProject):
                if 'project_type_id' in instance.changed_fields:
                    history_instance.history_change_reason = 'Design changed'
                elif 'language' in instance.changed_fields:
                    history_instance.history_change_reason = f'Language changed to {instance.get_language_display()}'
            elif isinstance(instance, ProjectType):
                if set(instance.changed_fields).intersection(['report_sections', 'finding_fields', 'finding_ordering']):
                    history_instance.history_change_reason = 'Field definition changed'
                elif 'language' in instance.changed_fields:
                    history_instance.history_change_reason = f'Language changed to {instance.get_language_display()}'
                elif 'status' in instance.changed_fields:
                    history_instance.history_change_reason = f'Status changed to {ReviewStatus.get_status_display(instance.status)}'

    if history_instance.history_change_reason:
        history_instance.history_prevent_cleanup = True


@receiver(post_create_historical_record, sender=ProjectNotebookExcalidrawFile.history.model)
def note_excalidraw_history(instance, history_instance, *args, **kwargs):
    # Create a history entry for the parent note when excalidraw file changes
    if history_instance.history_type != '~':
        return
    ProjectNotebookPage.history.create(
        **copy_keys(history_instance, ['history_type', 'history_date', 'history_change_reason', 'history_user', 'history_prevent_cleanup']),
        **{f.attname: getattr(instance.linked_object, f.attname) for f in ProjectNotebookPage.history.model.tracked_fields},
    )


@receiver(signals.post_delete, sender=PentestProject)
@receiver(signals.post_delete, sender=ProjectType)
@receiver(signals.post_delete, sender=FindingTemplate)
def delete_history(sender, instance, *args, **kwargs):
    # On delete of main models: cascade delete all related histories
    if sender == PentestProject:
        instance.history.all().delete()
        PentestFinding.history.filter(project_id=instance.id).delete()
        ReportSection.history.filter(project_id=instance.id).delete()
        ProjectNotebookPage.history.filter(project_id=instance.id).delete()
        ProjectMemberInfo.history.filter(project_id=instance.id).delete()
        UploadedProjectFile.history.filter(linked_object_id=instance.id).delete()
        UploadedImage.history.filter(linked_object_id=instance.id).delete()
        ProjectNotebookExcalidrawFile.history.filter(linked_object__project_id=instance.id).delete()
    elif sender == ProjectType:
        instance.history.all().delete()
        UploadedAsset.history.filter(linked_object_id=instance.id).delete()
    elif sender == FindingTemplate:
        instance.history.all().delete()
        FindingTemplateTranslation.history.filter(template_id=instance.id).delete()
        UploadedTemplateImage.history.filter(linked_object_id=instance.id).delete()


@receiver(signals.post_delete, sender=UploadedAsset)
@receiver(signals.post_delete, sender=UploadedAsset.history.model)
@receiver(signals.post_delete, sender=UploadedImage)
@receiver(signals.post_delete, sender=UploadedImage.history.model)
@receiver(signals.post_delete, sender=UploadedProjectFile)
@receiver(signals.post_delete, sender=UploadedProjectFile.history.model)
@receiver(signals.post_delete, sender=ProjectNotebookExcalidrawFile)
@receiver(signals.post_delete, sender=ProjectNotebookExcalidrawFile.history.model)
@receiver(signals.post_delete, sender=UploadedTemplateImage)
@receiver(signals.post_delete, sender=UploadedTemplateImage.history.model)
@receiver(signals.post_delete, sender=UploadedUserNotebookImage)
@receiver(signals.post_delete, sender=UploadedUserNotebookFile)
@receiver(signals.post_delete, sender=UserNotebookExcalidrawFile)
@receiver(signals.post_delete, sender=ArchivedProject)
def uploaded_file_deleted(sender, instance, *args, **kwargs):
    if not instance.file:
        return

    storage = sender.instance_type.file.field.storage if hasattr(sender, 'instance_type') else sender.file.field.storage

    # Delete file when instance is deleted from DB and file on filesystem is no loger referenced
    models = [
        UploadedAsset,
        UploadedImage,
        UploadedProjectFile,
        ProjectNotebookExcalidrawFile,
        UploadedTemplateImage,
        UploadedUserNotebookImage,
        UploadedUserNotebookFile,
        UserNotebookExcalidrawFile,
        ArchivedProject,
    ]
    qs = UploadedImage.objects.none()
    for m in models:
        if m.file.field.storage == storage:
            qs = qs.union(m.objects.filter(file=instance.file))
            if hasattr(m, 'history'):
                qs = qs.union(m.history.filter(file=instance.file))

    is_file_referenced = qs.exists()
    if not is_file_referenced:
        try:
            if isinstance(instance.file, str):
                storage.delete(instance.file)
            else:
                instance.file.delete(save=False)
        except (FileNotFoundError, OSError):
            # Ignore file not found. We would have deleted it anyway
            pass



@receiver(signals.post_save, sender=ProjectNotebookPage)
@receiver(signals.post_save, sender=UserNotebookPage)
@receiver(signals.post_save, sender=PentestFinding)
@receiver(signals.post_save, sender=ReportSection)
@receiver(signals.post_save, sender=PentestProject)
@receiver(signals.post_save, sender=Comment)
@receiver(signals.post_save, sender=CommentAnswer)
@receiver(signals.post_save, sender=ShareInfo)
@disable_for_loaddata
def collab_updated(sender, instance, created=False, *args, **kwargs):
    from sysreptor.pentests.consumers import send_collab_event_project, send_collab_event_user
    from sysreptor.pentests.serializers.notes import (
        ProjectNotebookPageSerializer,
        ProjectNotebookPageSortListSerializer,
        UserNotebookPageSerializer,
        UserNotebookPageSortListSerializer,
    )
    from sysreptor.pentests.serializers.project import (
        CommentSerializer,
        PentestFindingSerializer,
        ReportSectionSerializer,
    )

    if getattr(collab_context_store, 'prevent_events', False):
        return

    changed_fields = instance.changed_fields
    if isinstance(instance, PentestProject):
        if not created and (update_keys := set(changed_fields).intersection(['project_type_id', 'override_finding_order'])):
            for k in update_keys:
                path = 'project.' + (k[:-3] if k.endswith('_id') else k)
                send_collab_event_project(CollabEvent.objects.create(
                    related_id=instance.id,
                    type=CollabEventType.UPDATE_KEY,
                    path=path,
                    created=instance.updated,
                    version=instance.updated.timestamp(),
                    data={'value': getattr(instance, k)},
                ))
        return
    elif isinstance(instance, ShareInfo):
        if created:
            send_collab_event_project(CollabEvent.objects.create(
                related_id=instance.note.project_id,
                type=CollabEventType.UPDATE_KEY,
                path=f'notes.{instance.note.note_id}.is_shared',
                created=instance.note.updated,
                version=instance.note.updated.timestamp(),
                data={'value': True},
            ))
        return
    elif isinstance(instance, CommentAnswer):
        instance = instance.comment
        sender = Comment
        created = False
        changed_fields = ['answers']

    sender_options = {
        ProjectNotebookPage: {
            'send': send_collab_event_project,
            'path': f'notes.{getattr(instance, "note_id", None)}',
            'related_id': getattr(instance, 'project_id', None),
            'serializer': ProjectNotebookPageSerializer,
            'update_keys': ['checked', 'icon_emoji', 'assignee_id'],
            'update_text': ['title', 'text'],
        },
        UserNotebookPage: {
            'send': send_collab_event_user,
            'path': f'notes.{getattr(instance, "note_id", None)}',
            'related_id': getattr(instance, 'user_id', None),
            'serializer': UserNotebookPageSerializer,
            'update_keys': ['checked', 'icon_emoji'],
            'update_text': ['title', 'text'],
        },
        ReportSection: {
            'send': send_collab_event_project,
            'path': f'sections.{getattr(instance, "section_id", None)}',
            'related_id': getattr(instance, 'project_id', None),
            'serializer': ReportSectionSerializer,
            'update_keys': ['status', 'assignee_id', 'custom_fields'],
        },
        PentestFinding: {
            'send': send_collab_event_project,
            'path': f'findings.{getattr(instance, "finding_id", None)}',
            'related_id': getattr(instance, 'project_id', None),
            'serializer': PentestFindingSerializer,
            'update_keys': ['status', 'assignee_id', 'custom_fields'],
        },
        Comment: {
            'send': send_collab_event_project,
            'path': f'comments.{getattr(instance, "id", None)}',
            'related_id': getattr(instance, 'project_id', None),
            'serializer': CommentSerializer,
            'update_keys': ['text', 'answers', 'status'],
        },
    }[sender]

    if created:
        # Send create signal
        sender_options['send'](CollabEvent.objects.create(
            related_id=sender_options['related_id'],
            type=CollabEventType.CREATE,
            path=sender_options['path'],
            created=instance.created,
            version=instance.created.timestamp(),
            data={
                'value': sender_options.get('serializer_create', sender_options['serializer'])(instance).data,
            },
        ))
        # Send sort signal because creating a note in the middle of the list changes the order of all following notes
        if sender == ProjectNotebookPage:
            sorted_instances = instance.project.notes.select_related('parent').all()
            ProjectNotebookPageSortListSerializer(sorted_instances, context={'project': instance.project}) \
                .send_collab_event(sorted_instances)
        elif sender == UserNotebookPage:
            sorted_instances = instance.user.notes.select_related('parent').all()
            UserNotebookPageSortListSerializer(sorted_instances, context={'user': instance.user}) \
                .send_collab_event(sorted_instances)
    elif update_keys := set(changed_fields).intersection(sender_options['update_keys'] + sender_options.get('update_text', [])):
        update_text = set(sender_options.get('update_text', []))
        comments = list(instance.comments.all()) if hasattr(instance, 'comments') else []
        comments_to_update = []

        if 'custom_fields' in update_keys:
            update_keys.discard('custom_fields')
            updated_lists = set()
            new_values = {t[0]: t for t in iterate_fields(value=instance.data, definition=instance.field_definition)}
            old_values = {t[0]: t for t in iterate_fields(value=instance.get_field_diff('custom_fields')[0], definition=instance.field_definition)}
            for path in sorted(list(set(new_values.keys()).intersection(old_values.keys()))):
                old_value = old_values[path][1]
                new_value = new_values[path][1]
                path_str = '.'.join(('data',) + path)

                if any(path_str.startswith(p + '.') for p in updated_lists):
                    continue

                if new_values[path][2].type == FieldDataType.LIST and isinstance(old_value, list|tuple) and isinstance(new_value, list|tuple) and len(old_value) != len(new_value):
                    # List changed
                    update_keys.add(path_str)
                    updated_lists.add(path_str)
                    # Move comments paths to list
                    for c in comments:
                        if '.'.join(c.path.split('.')[:len(path) + 1]) == path_str:
                            c.path = path_str
                            c.text_range = None
                            comments_to_update.append(c)
                            comments.remove(c)
                elif old_value != new_value:
                    # Field updated
                    update_keys.add(path_str)
                    # Remove parent: only update leaf nodes
                    update_keys.discard('.'.join(('data',) + path[:-1]))

                    # Text updates
                    if new_values[path][2].type in [FieldDataType.MARKDOWN, FieldDataType.STRING] and \
                       new_values[path][2].type == old_values[path][2].type and \
                       isinstance(new_value, str) and isinstance(old_value, str):
                        update_text.add(path_str)

        events_to_send = []
        serialized_data = sender_options['serializer'](instance).data
        for k in update_keys:
            if k not in serialized_data and '.' not in k and k.endswith('_id'):
                k = k[:-3]

            if k in update_text:
                text_before = get_value_at_path(instance.initial['custom_fields'], k.split('.')[1:]) if k.startswith('data.') else instance.initial[k]
                text_after = get_value_at_path(instance.data, k.split('.')[1:]) if k.startswith('data.') else serialized_data[k]
                text_changes = ChangeSet.from_diff(text_before, text_after)
                # Update text ranges of comments
                comment_infos = []
                for c in comments:
                    if k == c.path and c.text_range:
                        try:
                            c.text_range = c.text_range.map(text_changes)
                        except ValueError:
                            c.text_range = None
                        comments_to_update.append(c)
                        comment_infos.append(c)
                        comments.remove(c)

                events_to_send.append(CollabEvent(
                    related_id=sender_options['related_id'],
                    type=CollabEventType.UPDATE_TEXT,
                    path=f"{sender_options['path']}.{k}",
                    created=instance.updated,
                    version=instance.updated.timestamp(),
                    data={
                        'updates': [Update(client_id=None, version=None, changes=text_changes).to_dict()],
                        'comments': [c.to_dict_short() for c in comment_infos],
                    },
                ))
            else:
                events_to_send.append(CollabEvent(
                    related_id=sender_options['related_id'],
                    type=CollabEventType.UPDATE_KEY,
                    path=f"{sender_options['path']}.{k}",
                    created=instance.updated,
                    version=instance.updated.timestamp(),
                    data={
                        'value': get_value_at_path(instance.data, k.split('.')[1:]) if k.startswith('data.') else serialized_data[k],
                    },
                ))

        Comment.objects.bulk_update(comments_to_update, fields=['path', 'text_range_from', 'text_range_to'])
        events_to_send = CollabEvent.objects.bulk_create(events_to_send)
        for e in events_to_send:
            sender_options['send'](e)


@receiver(signals.post_delete, sender=ProjectNotebookPage)
@receiver(signals.post_delete, sender=UserNotebookPage)
@receiver(signals.post_delete, sender=PentestFinding)
@receiver(signals.post_delete, sender=ReportSection)
@receiver(signals.post_delete, sender=Comment)
def collab_deleted(sender, instance, origin, *args, **kwargs):
    from sysreptor.pentests.consumers import send_collab_event_project, send_collab_event_user

    if getattr(collab_context_store, 'prevent_events', False) or isinstance(origin, PentestProject|PentestUser):
        return

    if isinstance(instance, PentestFinding|ReportSection|ProjectNotebookPage):
        sender_options = {
            'send': send_collab_event_project,
            'related_id': instance.project_id,
        }
        sender_options['path'] = {
            PentestFinding: f'findings.{getattr(instance, "finding_id", None)}',
            ReportSection: f'sections.{getattr(instance, "section_id", None)}',
            ProjectNotebookPage: f'notes.{getattr(instance, "note_id", None)}',
        }[sender]
    elif isinstance(instance, UserNotebookPage):
        sender_options = {
            'send': send_collab_event_user,
            'related_id': instance.user_id,
            'path': f'notes.{instance.note_id}',
        }
    elif isinstance(instance, Comment) and hasattr(origin, 'project_id'):
        sender_options = {
            'send': send_collab_event_project,
            'related_id': origin.project_id,
            'path': f'comments.{instance.id}',
        }
    else:
        return

    sender_options['send'](CollabEvent.objects.create(
        related_id=sender_options['related_id'],
        type=CollabEventType.DELETE,
        path=sender_options['path'],
        created=timezone.now(),
        version=timezone.now().timestamp(),
        data={},
    ))


@receiver(signals.post_save)
@disable_for_loaddata
def post_create_proxy(sender, instance, created, *args, **kwargs):
    if created and not getattr(instance, 'skip_post_create_signal', False):
        sysreptor_signals.post_create.send(sender=sender, instance=instance)


@receiver(signals.post_save)
@disable_for_loaddata
def post_update_proxy(sender, instance, created, *args, **kwargs):
    if created:
        return
    changed_fields = set(getattr(instance, 'changed_fields', []))
    if 'custom_fields' in changed_fields and isinstance(instance, CustomFieldsMixin):
        changed_fields.add('data')
        updated_lists = set()
        new_values = {t[0]: t for t in iterate_fields(value=instance.data, definition=instance.field_definition)}
        old_values = {t[0]: t for t in iterate_fields(value=instance.get_field_diff('custom_fields')[0], definition=instance.field_definition)}
        for path in sorted(list(set(new_values.keys()).intersection(old_values.keys()))):
            old_value = old_values[path][1]
            new_value = new_values[path][1]
            path_str = '.'.join(('data',) + path)

            if any(path_str.startswith(p + '.') for p in updated_lists):
                continue

            if new_values[path][2].type == FieldDataType.LIST and isinstance(old_value, list|tuple) and isinstance(new_value, list|tuple) and len(old_value) != len(new_value):
                # List changed
                changed_fields.add(path_str)
                updated_lists.add(path_str)
            elif old_value != new_value:
                # Field updated
                changed_fields.add(path_str)
                # Remove parent: only update leaf nodes
                changed_fields.discard('.'.join(('data',) + path[:-1]))

    sysreptor_signals.post_update.send(sender=sender, instance=instance, changed_fields=changed_fields)
